Skip to content

feat: add Swarm permission manifests - #168

Merged
meinharrd merged 11 commits into
mainfrom
codex/permission-manifests-v1
Aug 14, 2026
Merged

feat: add Swarm permission manifests#168
meinharrd merged 11 commits into
mainfrom
codex/permission-manifests-v1

Conversation

@flotob

@flotob flotob commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • discover and strictly validate freedom-manifest.json for committed bzz: applications
  • add consolidated three-outcome consent, per-navigation freshness gating, Settings visibility, individual-approval downgrade, and coordinated disconnects
  • project publish, feed, signing, and messaging grants with provenance, shared ownership, receipts, atomic journaling, and startup recovery
  • serialize manifest, permission, feed, identity, and Settings mutations through a per-origin main-process mutex
  • add session backoff for unresolved manifests without pruning existing authority

Why

Swarm applications currently request connection and operation permissions incrementally. A co-deployed manifest lets the browser present one understandable permission decision while binding automatic grants to the capability set the application continues to declare. Provenance and lifecycle pruning ensure manual overrides remain user-owned and removed capabilities do not retain manifest-managed authority.

This PR targets feature/window-swarm-enhancements because messaging is part of the capability registry and projection model.

Validation

  • npm run lint
  • npm test — 112 suites and 2,247 tests passed; 10 existing skips
  • unsigned macOS ARM64 build via npm run build -- --mac --arm64 --unsigned
  • real-store fault injection across the journal, all permission/feed atomic writes, every projection boundary, and the final manifest commit

flotob added 2 commits July 20, 2026 13:13
Discover and validate bzz-hosted capability manifests before privileged provider requests. Project approved grants with provenance, recovery, lifecycle pruning, consent UI, and Settings controls.
Queue manifest, permission, and feed mutations per origin to close cross-window races. Add unresolved-fetch backoff and exhaustive recovery coverage across journal and authority-store write boundaries.
@flotob
flotob marked this pull request as ready for review July 20, 2026 13:34
flotob added 2 commits July 20, 2026 22:34
Extract the interoperable discovery, schema, capability, consent lifecycle, and security contract from the browser implementation. Include the detailed design as non-normative rationale and call out the versioning boundary SwarmID must coordinate around.
Document fail-closed behavior for unresolved tracked manifests and add an informative provider-method mapping. Distinguish base-connection operations from capability-gated methods so the profile matches the reference implementation.
# Conflicts:
#	src/main/index.js
#	src/main/preload.test.js
#	src/main/swarm/feed-store.js
#	src/renderer/lib/wallet/permission-manage.js
@meinharrd
meinharrd changed the base branch from feature/window-swarm-enhancements to main August 12, 2026 22:06
@meinharrd

Copy link
Copy Markdown
Contributor

Rebuilt on main and retargeted (base feature/window-swarm-enhancementsmain) now that #165 is merged. Feature-only diff: 24 files, all manifest/permission code, no parent commits. Conflict resolutions:

  • feed-store.js: kept both imports (withOriginLock + main's WALLET_TYPES).
  • preload.test.js: grafted the swarmManifest exposure + 5 IPC rows + revokeMessaging onto main's list (count → 25).
  • index.js: took main's IPC registrations.
  • permission-manage.js — one behavior decision worth a look: this branch had removed the ensurePublisherIdentityUnlocked vault-unlock gate on showSwarmPermissions, but Hardware wallet support: Ledger accounts across wallet, dApps, and x402 #149 (now in main) added/kept it as a security gate. I preserved main's unlock gate (restored the function + showVaultUnlock import) since it's the current security posture and the manifest UI renders fine after it — but if the removal was intentional for the manifest flow, flag it.

Full swarm/permission suites pass; the only failing unit tests in my bare worktree are the known missing-native-dep ones (@ledgerhq/@ghostery, installed in CI) + the vault flake.

@meinharrd meinharrd added the alan:reviewing alan loop currently running on this PR label Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Blocking — app fails to launch: duplicate registerSwarmProviderIpc() in src/main/index.js

bootstrap() now calls registerSwarmProviderIpc() twice — line 311 (main's original) and line 316 (added right after registerPermissionManifestIpc(), looks like a rebase artifact of the retarget onto main). ipcMain.handle throws on a second handler for the same channel, so bootstrap dies before createMainWindow and no window ever opens.

Empirically confirmed on this branch (xvfb-run npx electron .):

[PermissionManifests] IPC handlers registered
Unhandled rejection: Error: Attempted to register a second handler for 'swarm:provider-execute'
    at registerSwarmProviderIpc (src/main/swarm/swarm-provider-ipc.js:1997:11)
    at bootstrap (src/main/index.js:316:3)

Every harness e2e spec fails with electronApplication.firstWindow: Target page, context or browser has been closed (30s launch timeout) — e.g. all 4 tests in address-bar-clipboard.spec.js. The unit suites (which never touch index.js) all pass, which is why npm test looked green in the PR validation.

Fix: delete line 316. Note this also blocked any visual verification of the manifest consent UI in this round — please re-run a harness spec after the fix.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Blocking — showPermissionManifest bypasses the prompt queue; a second concurrent consent hangs the first request and bricks the sheet (src/renderer/lib/wallet/permission-manifest.js)

Every sibling swarm prompt (swarmConnectQueue, swarmPublishQueue, swarmMessagingQueue, swarmFeedQueue in swarm-connect.js) goes through createPromptQueue, whose showNext() deliberately keeps current null while present() runs hideAllSubscreens() "so the hider cannot mistake the incoming request for a dismissed one". The new manifest sheet uses a single module-level pending slot instead, and its own screen-hider settles 'deny' whenever the screen was visible and pending is set.

Concrete failure — two tabs on different manifest-bearing bzz apps, each issues a non-public swarm method, sheet #1 is on screen when consent #2 arrives:

  1. showPermissionManifest (call 2) overwrites pendingcall 1's promise can never settle; its swarm request hangs forever (no decide, no rejection; the token just expires silently).
  2. Call 2 then runs hideAllSubscreens() → the manifest hider sees wasVisible && pendingsettle('deny')call 2 is auto-denied with no user input, pending = null, screen hidden.
  3. Execution continues in call 2: screen.classList.remove('hidden') — the sheet is re-shown for app 2, but pending is null, so Allow / Individual / Reject / Back all hit settle()'s if (!pending) returndead buttons; the sheet stays stuck until some other prompt happens to run hideAllSubscreens().

Related sibling gap in the same file: the queue-based prompts also pre-check isSignatureInFlight() and reject with signatureInFlightError(); showPermissionManifest doesn't — it relies on hideAllSubscreens()'s assertNoSignatureInFlight() throwing inside the Promise executor, which rejects the request with a generic error and leaves a stale pending behind.

Fix suggestion: route the sheet through createPromptQueue like its four siblings (present/settle + armed-window), or at minimum settle-and-reject the old pending before overwriting it and add the signature-in-flight pre-check.

(Verified by code trace against wallet-state.js's hideAllSubscreens/registerScreenHider; runtime repro was not possible this round because the bootstrap crash above prevents the app from launching at all.)

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Minor findings (combined):

  1. checkManifest prunes manifest authority on 'unsupported' discover status (src/main/swarm/permission-manifests.js:347-351). discover() returns 'unsupported' whenever the committed URL isn't bzz: (or doesn't parse), and checkManifest treats that like "manifest gone" → pruneRecord(key), revoking manifest-managed connection/auto-approvals/feeds. Permission keys are transport name-keyed (bzz://myapp.eth and ipfs://myapp.eth both → myapp.eth), so if the app's ENS contenthash later moves to IPFS (or any non-bzz transport view of the same name commits) and the page issues a swarm request, the record is silently pruned even though the user never changed anything. Also note the normalizeOrigin(committedUrl) !== key guard sits after the prune branch, so a mismatched origin/committedUrl pair (only reachable from a misbehaving renderer today, since both derive from displayUrl) could prune origin A's record based on origin B's site. Suggest: treat 'unsupported' (and move the mismatch guard) as plain {kind:'legacy'} with no prune — reserve pruning for a definitive bzz-side absent/invalid.

  2. decideManifest 'individual' grants connection outside the journal (permission-manifests.js:418-420). permissions.grantPermission(pending.origin) runs before runTransaction journals anything; a crash between the two leaves the connection granted with no record/receipt/acknowledgement, unlike every other mutation in this flow which is replayed via state.pending recovery. Could be modeled as a {projection:'connection', enabled:true} operation instead.

  3. Fingerprint ignores why texts → receipt provenance can mismatch (permission-manifests.js:160-165, 362, 380-383, 448). fingerprint hashes only schema + capability keys, and the else-branch re-check save updates observed.rawHash without bumping revision. So while a consent token is outstanding, a re-check (another webview, new navigation) that only changed why texts keeps the token valid, and the eventual receipt stores the new rawHash alongside the old whyShown strings — the receipt then attests text that isn't in the manifest it hashes.

  4. Feed-store user mutations don't detach manifest ownership (sibling asymmetry with swarm-permissions.js). User-sourced revokePermission/setAutoApprove/grant|revokeMessaging all fire notifyManifestMutationdetachManaged, but SWARM_REVOKE_FEED_ACCESS (and identity changes) in feed-store.js notify nothing — after a user revokes feed access via that IPC, record.managed.feedGrant still claims manifest ownership and the Settings manifest section keeps showing "feeds · allowed by manifest". Low impact today (the only renderer caller is the non-manifest fallback in disconnectSwarmApp), but the IPC surface exists and the mirror-path lesson from fix(context-menu): support bzz/ipfs/ipns URLs in save and copy image #158 applies.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R1] Both findings verified against dc3e91d.

R1-F1 — CONFIRMED (blocking). src/main/index.js calls registerSwarmProviderIpc() at line 311 and line 316, and registerSwarmProviderIpc() calls ipcMain.handle(IPC.SWARM_PROVIDER_EXECUTE, …) unconditionally (no removeHandler/registered guard), so the second call throws inside bootstrap() — before createMainWindow() at line 393 — and there is no try/catch on that path (app.whenReady().then(bootstrap), line 440). Reproduced by launching the app directly:

[PermissionManifests] IPC handlers registered
Unhandled rejection: Error: Attempted to register a second handler for 'swarm:provider-execute'
    at registerSwarmProviderIpc (src/main/swarm/swarm-provider-ipc.js:1997:11)
    at bootstrap (src/main/index.js:316:3)

No window ever opens; xvfb-run npx playwright test --project=harness test-e2e/tabs.spec.js fails 2/2 at the 30s firstWindow() setup. Deleting line 316 locally makes the same spec pass 2/2 in 16s, so the duplicate call is the sole cause.

R1-F2 — CONFIRMED (blocking). showPermissionManifest() assigns the single module-level pending slot (line 34) before calling hideAllSubscreens() (line 74), so a second in-flight consent overwrites the first resolver and then trips its own screen hider (wasVisible && pendingsettle('deny')), after which line 76 re-shows the sheet with pending === null. Reproduced with a throwaway jest repro against the real module (fake DOM, sheet starting hidden as in index.html): call 2 resolves 'deny' with no user input, call 1 never settles, the sheet stays visible, and Allow / Individual / Reject all no-op. Concurrency is reachable without exotic timing — the renderer dedupe cache in swarm-provider.js is per-webview (manifestChecks WeakMap), and main's withOriginLock is per-origin, so two tabs on different manifest-bearing origins are unserialized; the same-tab case also exists, since if (!cached || (eager && !cached.eager)) starts a second refresh when a swarm_requestAccess arrives while a non-eager consent is already on screen. The sibling prompts in wallet/swarm-connect.js already solve exactly this with createPromptQueue (its header comment describes this precise failure mode), so the fix is to route this sheet through the same queue.

Sheet as it renders today (captured with line 316 removed locally so the app can start) — in the concurrent case it looks identical but is inert:

manifest-sheet

Side note for the fix round, not a new finding: test-e2e/ has no harness spec covering the manifest sheet at all, which is why F1 was only caught by the whole suite failing at launch.

Working tree left clean; throwaway repro/capture files deleted.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
…consents

registerSwarmProviderIpc() was called twice in bootstrap(); ipcMain.handle
throws on the second registration for swarm:provider-execute, so bootstrap
rejected before createMainWindow() and the app never opened a window.

showPermissionManifest() kept a single overwritable `pending` slot, so two
concurrent consent requests clobbered each other: the first hung forever,
the second was auto-denied by its own hideAllSubscreens() transition, and
the sheet stayed on screen with dead buttons. Move the sibling prompts'
createPromptQueue into a shared prompt-queue.js and use it for the manifest
sheet too — concurrent consents queue, dismissal denies all of them, and
claim() gives the same double-click protection as the other prompts.
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R1] Both confirmed findings fixed in 2a3a555.

R1-F1 — duplicate registerSwarmProviderIpc() (blocking)src/main/index.js
Removed the second call added next to registerPermissionManifestIpc(). ipcMain.handle throws on the re-registration of swarm:provider-execute, so bootstrap() rejected before createMainWindow() and no window ever opened. Empirically confirmed fixed: xvfb-run -a npx playwright test --project=harness test-e2e/tabs.spec.js now passes (2/2) where every harness spec previously died at the 20s launch timeout.

R1-F2 — manifest consent used a single overwritable pending slot (blocking)src/renderer/lib/wallet/permission-manifest.js
Root cause was that this sheet didn't use the queueing the sibling prompts already have. Extracted createPromptQueue / PROMPT_ARM_DELAY_MS / setButtonsDisabled from swarm-connect.js into a shared src/renderer/lib/wallet/prompt-queue.js (pure move, no behavior change for the connect/publish/messaging/feed prompts) and put the manifest sheet on a queue of its own:

  • concurrent consents queue instead of clobbering — request 1 no longer hangs forever and request 2 is no longer auto-denied by its own hideAllSubscreens() transition;
  • the screen hider bails while presenting (our own transition) and otherwise drain()s, resolving 'deny' for the on-screen and queued requests, so nothing is left pending;
  • settling goes through claim(), so a double-click can't settle a prompt twice or land on the prompt that just took the screen; buttons are disabled during the 500ms input-protection window, same as the siblings.

New unit tests in src/renderer/lib/wallet/permission-manifest.test.js (queueing, dismissal denying queued requests, double-click protection, arm-window button state) — all 4 fail against the pre-fix module and pass after.

Verification

  • npx jest: 2877 passed, 1 failed — only the known pre-existing vault auto-locks after timeout flake.
  • npx eslint clean on all touched files.
  • Harness e2e: tabs.spec.js 2/2 (launch regression gone), plus a throwaway spec that drove two concurrent showPermissionManifest() calls in the real app (deleted before commit). Outcomes asserted [['first','allow'], ['second','deny']].

Acceptance evidence — request 1 on screen (captured inside the input-protection window, hence the dimmed Allow all), then request 2 taking the screen with live buttons after request 1 is allowed:

manifest-first-consent
manifest-queued-consent

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R2] Blocking — concurrent same-origin consents mint independent tokens; after the first decide, the second Allow throws `Manifest consent is stale` and fails the page's request (src/main/swarm/permission-manifests.js:386-396, 407-414)

The R1-F2 queue fix makes concurrent manifest consents sequential in the UI, but main still mints a fresh token per checkManifest call with baseRevision captured at check time. Two checks for the same origin before any decide (the renderer dedupe cache in swarm-provider.js is per-webview, so two tabs of the same app are unserialized; withOriginLock serializes the checks but happily runs both) yield token1 and token2 with the same baseRevision. decideManifest(token1, 'allow') bumps record.revision, so decideManifest(token2, …) always hits the revision guard at line 411 and throws.

Concrete failure — user opens the same manifest-bearing bzz app in two tabs; both pages call swarm_requestAccess on load:

  1. Both eager checks return kind:'consent' (token1, token2, same baseRevision). Sheet A shows; sheet B queues behind it (per the R1 fix).
  2. User clicks Allow on sheet A → everything grants, tab A connects.
  3. The queue presents sheet B — an identical consent for the same app. User clicks Allow again → decide(token2) throws Manifest consent is staleperformManifestRefresh rejects → tab B's swarm_requestAccess fails with an internal error even though the user just consented twice. (A page-initiated retry would succeed — the cache entry is dropped and the re-check returns ready — but the page has no reason to know that.)

The deny-then-allow variant is worse for intent: user denies in tab A (first-contact record deleted), then answers sheet B either way → same stale throw, so tab B's answer is never honored.

Empirically confirmed against the real module (jest repro with the store on a temp dir, mocked bzz fetch — same harness as permission-manifests.test.js; repro deleted):

second decide result:        THREW: Manifest consent is stale   (allow after allow)
deny-then-allow decide result: THREW: Manifest consent is stale

The renderer leg (second identical sheet presented from the queue) is exactly the flow shown in the R1 fix round's manifest-queued-consent screenshot — but with the same origin in both tabs, the second Allow now errors instead of resolving.

Fix suggestion: reuse an outstanding token for the same (origin, fingerprint, revision) in checkManifest instead of minting a new one — then the existing completedTokens idempotency absorbs the second decide and the queued duplicate sheet resolves cleanly. Alternatively (or additionally), in decideManifest, before throwing on a stale revision, treat the decide as already-satisfied when every capability in pending.changed is acknowledged under the same fingerprint, returning the recorded result.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R2] Minor findings (combined):

  1. Signature-in-flight pre-check is still missing on the manifest sheet after the R1-F2 fix (src/renderer/lib/wallet/permission-manifest.js). All four sibling prompts pre-check isSignatureInFlight() and reject with signatureInFlightError() before touching the queue; showPermissionManifest still relies on presentPermissionManifest()'s hideAllSubscreens()assertNoSignatureInFlight() throwing. On the direct-show path this now rejects the promise (the entry never becomes current, so queue state stays clean), but the page gets a generic internal error instead of the standard in-flight rejection — and if the throw ever happens on the queued transition (settle()showNext()present()), the shifted entry is lost with its promise never settled. Today the queued path looks unreachable (starting a signature drains this queue via its own hideAllSubscreens()), so this stays minor — but the one-line pre-check before manifestQueue.show() closes both.

  2. checkManifest's no-mutation branch saves a store snapshot captured before the await (src/main/swarm/permission-manifests.js:326, 380-383). state is read at line 326, then discover() is awaited; the else-branch does state.records[key] = record; saveStore(). saveStore() serializes storeCache, not state — if a concurrent operation on another origin failed its save during the await (which nulls storeCache), this branch either silently drops the record update or writes JSON.stringify(null) over the store file (recovered as an empty store on next load, losing all manifest records without touching the .bak). Narrow (needs a concurrent fault mid-discover), but runTransaction already shows the fix: re-loadStore() after the await before mutating.

  3. Abandoned consent tokens are never purged (permission-manifests.js:41, 386-396). tokens entries are deleted only on decide (or stale-decide); a sheet dismissed via hideAllSubscreens() resolves 'deny' in the renderer without calling decide when the model came from a dropped queue entry… actually the drained entries do resolve 'deny' and performManifestRefresh then calls decide, so the common path cleans up — but tokens from navigations abandoned mid-check (webview destroyed, renderer error before decide) accumulate for the session. Trivial: sweep expiresAt < Date.now() entries at the top of checkManifest.

Note: the four R1 minor findings (prune-on-unsupported, individual-grant outside the journal, fingerprint vs. why texts/receipt provenance, feed-store mutations not detaching manifest ownership) remain unaddressed as of 2a3a555 — not repeating them here, just flagging they're still open.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R2] R2-F1 — CONFIRMED.

Two same-origin checkManifest calls (the two-tab case: manifestChecks in swarm-provider.js is a WeakMap keyed by webview, so each tab checks independently) both return kind:'consent' with distinct tokens carrying the same baseRevision — the first check writes only observed/app and does not bump revision, and nothing dedupes an in-flight consent per origin. After the first decideManifest(..., 'allow') sets record.revision = baseRevision + 1, the second token trips the guard at permission-manifests.js:411 and throws Manifest consent is stale, which performManifestRefresh propagates so tab B's swarm_requestAccess rejects even though the user just approved the identical sheet.

Reproduced deterministically against the real module (temp jest spec, since deleted), both bare and wrapped in withOriginLock('app.eth', …) exactly as the IPC handler does — the lock serializes the two checks but does not prevent them both minting tokens at the same revision:

A: consent B: consent            (changed: 1 / 1)
second decide error: Manifest consent is stale
locked A: consent B: consent
locked second decide error: Manifest consent is stale
deny-then-allow error: Manifest consent is stale   (firstContact deny deletes the record → fingerprint guard fires)

Visually confirmed too (harness e2e, temp spec since deleted): two concurrent showPermissionManifest() requests queue and the user is shown the identical sheet a second time after allowing the first — below is the second presentation, which is the one whose Allow fails in main.

r2f1-duplicate-sheet

Refutation attempts that failed: the per-origin mutex only orders the calls; completedTokens is keyed by token so it can't absorb the sibling; the renderer has no retry-on-stale path (ensureManifestFresh just drops the cache entry and rethrows to the page); and the prompt queue has no per-origin dedupe.

Two tabs of the same manifest-bearing app both call swarm_requestAccess on
load. Each check minted its own token off the same baseRevision, so the user
was shown two identical sheets and the second decide() failed the revision
guard the first decision had just bumped — the second tab's request rejected
with "Manifest consent is stale" despite the user having consented (the
first-contact deny/allow variant tripped the fingerprint guard the same way).

checkManifest() now hands back the outstanding token when an undecided
consent for the same origin, manifest fingerprint, base revision, and
capability set already exists, so the second decide() replays the recorded
result instead of throwing. `firstContact` is deliberately excluded from that
match: the first check persists its observation, so a sibling check sees a
record and computes false — keeping the outstanding token's value is what
still drops the observation on a denial.

The sheet coalesces on that shared token too, so the second tab rides on the
one answer rather than re-asking an identical question (and a late arrival on
an already-answered token replays it without a sheet).
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R2] — fixed in ac35f64.

R2-F1 — concurrent same-origin consents mint independent tokens (blocking)

Root cause: checkManifest() minted a fresh token on every check, so two tabs
of the same app both got a token at the same baseRevision. The first
decision bumped the revision, and the second decideManifest() then failed
the revision guard (or, on the first-contact deny path, the fingerprint guard)
— the second tab's swarm_requestAccess rejected with an internal error even
though the user had consented.

Fix — one outstanding consent per (origin, manifest fingerprint, base
revision, capability set):

  • src/main/swarm/permission-manifests.jscheckManifest() reuses the
    still-undecided token that matches the candidate consent instead of minting
    a second one (expired tokens are swept on the way past). The second
    decide() then lands on the existing completedTokens replay and returns
    the recorded result. firstContact is deliberately not part of the match:
    the first check persists its observation, so a sibling check a moment later
    sees a record and computes false — keeping the outstanding token's value is
    what still drops the observation when the user denies.
  • src/renderer/lib/wallet/permission-manifest.js +
    src/renderer/lib/swarm-provider.js — the sheet coalesces on that shared
    token, so the second tab rides on the single answer instead of queueing an
    identical sheet the user has to answer twice; a tab arriving on an
    already-answered token replays the answer with no sheet at all. The queue
    still shows distinct consents in turn.

Nothing was loosened in the staleness guards: a manifest that actually changed
between the two checks still yields a different token and still makes the older
token stale (covered by a new test).

Tests: 3 new main-process cases (shared token + both decisions succeed; shared
first-contact denial drops the observation for both tabs; changed manifest
still invalidates) and 1 new renderer case (one token → one sheet → one
answer settles both). All four fail on the pre-fix code. Full unit suite:
2881 passed, 1 failure — the known pre-existing vault auto-locks after timeout flake. eslint clean on the touched files.

Verified in the real app (harness Electron run: real main-process
authority and real sidebar sheet, only the bzz manifest fetch injected; the
driver spec was throwaway and is not committed):

Before (at 2a3a555) — after answering the first sheet, an identical second
sheet is still on screen, and the second tab's decide fails with
Error invoking remote method 'swarm:manifest-decide': Error: Manifest consent is stale:

before-duplicate-sheet

After (ac35f64) — two tabs, one sheet:

after-single-sheet

…and one "Allow all" settles both tabs — sheet gone, no duplicate queued
behind it, both decide() calls return { allowed: true, mode: 'allow' }
with a single receipt recorded:

after-one-allow-settles-both

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R3] Blocking — a transient network error mid-body during manifest fetch is classified invalid and prunes all manifest-managed authority (src/main/swarm/permission-manifests.js:194-196, 381-384)

discover() only classifies failures as unresolved (→ backoff, authority preserved) when they happen before response headers: a fetchManifest rejection or a 5xx status. Anything thrown after headers — including a connection reset while streaming the body — lands in the readLimitedJson catch block and returns { status: 'invalid' }, and checkManifest treats every non-found status as definitive: if (existing) pruneRecord(key).

handleBzzRequest makes this window real: fetchOnce clears its 30s attempt timer in finally, i.e. as soon as the fetch promise resolves with headers, and returns the still-streaming Response. So a Bee node restarting / gateway connection dropping after 200 OK + first bytes surfaces as a reader.read() rejection inside readLimitedJson — not as a fetch rejection.

Concrete failure: user has answered "Allow all" for a manifest-bearing app (managed connection, auto-approve publish/feeds, feed grant, app-scoped identity). On a later navigation the page issues any non-public swarm method → checkManifest refetches the manifest → headers arrive, then the socket resets mid-body → pruneRecord runs a full removal transaction: connection revoked (revoke listeners cancel live subscriptions), feed grant revoked, auto-approvals cleared, record deleted — the user's authority is destroyed by a transient network hiccup the unresolved backoff was explicitly designed to absorb ("session backoff for unresolved manifests without pruning existing authority", per the PR description).

Empirically confirmed against the real module (temp jest spec using the same harness as permission-manifests.test.js, since deleted):

discover status for mid-body ECONNRESET:      {"status":"invalid","error":"read ECONNRESET"}
checkManifest after transient stream error:   {"kind":"legacy","pruned":true,"reason":"invalid"}
record after:            null
permission state after:  null            ← connection gone
feed state after:        {"identity":true,"granted":false}   ← feed grant revoked
--- contrast: same error thrown pre-headers ---
checkManifest after pre-headers error:        {"kind":"unresolved","retryAt":...}   ← authority preserved

Fix suggestion: in discover(), separate the body-read from parse/validate — a reader.read() rejection (stream/transport error) should classify as 'unresolved' exactly like a pre-headers failure; reserve 'invalid' for a fully-received body that fails JSON parse, schema validation, or the 8 KiB cap. Related hardening while in there (see minor #1): give the body read a deadline, since the 30s attempt timeout stops covering the response once headers arrive.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R3] Minor findings (combined):

  1. Manifest body read has no deadline — a stalled body hangs every non-public swarm request for the origin until the process-level body timeout fires (src/main/swarm/permission-manifests.js:137-158 + src/main/swarm/bzz-protocol.js:285-301). The per-attempt 30s timer in fetchOnce is cleared in finally the moment headers arrive, and readLimitedJson's reader.read() loop has no timeout of its own (new Request(manifestUrl) carries no signal). A gateway that sends 200 OK and then stalls (half-open connection) leaves checkManifest pending, and ensureManifestFresh caches that pending promise per navigation — so all non-public methods for the origin hang behind it. In practice undici's default bodyTimeout (~300s inactivity) eventually errors the read — a multi-minute hang — and that error then funnels into the R3 blocking finding's wrongful prune. Mirrors the PR fix(context-menu): support bzz/ipfs/ipns URLs in save and copy image #158 lesson: the timeout must cover headers and body. Fix alongside the blocking finding: inactivity deadline on the read loop, and classify its expiry as unresolved.

  2. Status tracker for previously-raised open items (not new findings): R1 minors 1–4 (prune on 'unsupported' + origin-mismatch guard ordering, individual connection grant outside the journal, fingerprint ignoring why texts → receipt provenance drift — mildly widened by ac35f64 since a reused token can now carry stale whyShown next to a newer observed.rawHash, feed-store/identity mutations not detaching manifest ownership) and R2 minors 1–2 (signature-in-flight pre-check on the manifest sheet, else-branch saveStore() using a pre-await snapshot) remain unaddressed as of ac35f64. R2 minor 3 (abandoned-token accumulation) is now effectively covered by outstandingToken()'s expired-token sweep.

Verification notes for this round: full swarm/manifest unit suites (7 suites, 133 tests) pass on ac35f64; harness e2e tabs.spec.js passes 2/2 (launch regression from R1 still fixed). Working tree left clean; throwaway repro spec deleted.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R3] R3-F1 (src/main/swarm/permission-manifests.js:382) — CONFIRMED.

Tried to refute it three ways and it survived all three:

  1. "Maybe handleBzzRequest buffers, so reader.read() can never see a network error." It does not — handleBzzRequest returns the undici Response straight from fetchWithRetry, body unread, so headers-vs-body failures are genuinely separable. Pre-header failures come back as jsonErrorResponse(502/503)!response.ok, status >= 500unresolved; a reset after headers surfaces only at reader.read() inside readLimitedJson, which is inside discover()s single try whose catch returns {status: invalid} (line 195).
  2. "Maybe the retry loop absorbs it." fetchWithRetry only retries on a rejected fetchImpl call or a retryable status; a stream error after the fetch promise resolves is outside the loop. The attempt timer is also cleared in finally once headers land, so it is not the timeout aborting the body — it is a real transient reset.
  3. "Maybe nothing calls this on a live navigation." swarm-provider.js ensureManifestFresh() runs on every non-public swarm method per navigation → SWARM_MANIFEST_CHECKcheckManifest, and with an existing record it always reaches discover().

Deterministic repro (throwaway jest spec, run and deleted — repo left clean). Same origin, managed publish + feeds granted via an allow decision:

  • identical ECONNRESET/terminated error before headers → {kind: "unresolved", retryAt: ...}, record intact, permission {autoApprove:{publish,feeds}}, feed {identity:true,granted:true}.
  • identical error mid-body (200 + first bytes, then controller.error) → discover = {status:"invalid"}, checkManifest = {kind:"legacy",pruned:true,reason:"invalid"}, getRecord = null, permission state undefined, feed grant false.

So a transient hiccup that lands one packet later than the one the unresolved backoff was built for silently destroys the whole managed grant set (connection revoked → live subscriptions torn down via the revoke listeners, feed grant revoked, auto-approvals cleared, record deleted) with no user-visible cause. Verdict: blocking, as reported. Fix direction: classify stream/transport failures from readLimitedJson as unresolved and keep invalid for what it means — bytes that arrived intact but failed size/JSON/schema validation (i.e. separate the read from the parse/validate, or tag transport errors and re-map them).

A manifest fetch that returned 200 and then died mid-body (bee restart,
dropped socket) surfaced from discover() as {status:'invalid'} — the same
verdict as a malformed manifest — because readLimitedJson's reader.read()
rejection landed in the same catch as JSON.parse and validateManifest
failures. checkManifest then ran pruneRecord for the origin: connection
revoked, feed grant revoked, auto-approvals cleared, record deleted. A
transient hiccup the unresolved backoff exists to absorb destroyed the
user's manifest-managed authority.

Tag read failures as transport errors and classify them 'unresolved' so
they feed the backoff. Only bytes we hold and cannot accept — oversized,
non-JSON, schema-violating — stay 'invalid'. Also cancel the reader on
the mid-body failure path.
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R3] — fixed in 3460107

R3-F1 (blocking, src/main/swarm/permission-manifests.js) — transient mid-body failure classified invalid, pruning manifest-managed authority.

Root cause: readLimitedJson's reader.read() rejection landed in the same catch as JSON.parse and validateManifest failures, so "the socket died halfway through the body" and "the server served garbage" produced the same {status:'invalid'} verdict. checkManifest then ran pruneRecord.

Fix: split byte-reading out into readLimitedBody and tag genuine transport failures (reader.read() rejecting, response.arrayBuffer() rejecting) with a transport marker; discover maps those to unresolved so they feed the existing backoff. Only bytes we actually hold and cannot accept — oversized, non-JSON, schema-violating — stay invalid. The reader is now also cancelled on the mid-body failure path (previously only on the size-limit path).

Tests (both fail without the fix, verified by reverting the one-line classification):

  • treats a body severed mid-stream as transient, not as a bad manifest — unit-level on discover().
  • keeps managed grants when the manifest body dies mid-stream — end-to-end acceptance for the reported scenario: allow a publish+feeds manifest, then a 200-then-severed re-fetch; asserts kind: 'unresolved', both auto-approvals still true, feed grant still true, record still present.

Existing a definitive disappearance prunes managed authority… (404) and rejects non-JSON and streams no more than 8 KiB still pass, so the pruning path is unchanged for genuinely-bad content.

Checked the sibling manual-body-reader sites for the same misclassification: src/main/http-fetch.js and src/main/tezos-domains-resolver.js both propagate read errors as plain failures with no invalid/transient split and no durable state pruning, so neither has the equivalent bug.

Verification: full unit suite 2883 passed, 1 failure — the known pre-existing vault auto-locks after timeout flake. eslint clean on both touched files. test-e2e/permissions.spec.js (harness) 5 passed as a smoke check.

No screenshot: this change has no UI surface — it alters how a backend fetch failure is classified, and the network-free harness project can't produce a 200-then-severed body. The user-visible consequence (silently revoked permissions) is covered by the end-to-end assertion above.

@meinharrd meinharrd added alan:needs-human alan hit max rounds with findings still open alan:reviewing alan loop currently running on this PR and removed alan:reviewing alan loop currently running on this PR alan:needs-human alan hit max rounds with findings still open labels Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (fresh review pass at 3460107, after the previous loop's R1–R3 fixes) — no blocking findings. Minor findings (combined):

  1. Consent token TTL can expire under a sheet that still has live buttons (src/main/swarm/permission-manifests.js:21,470 + src/renderer/lib/wallet/permission-manifest.js). TOKEN_TTL_MS is 5 min and decideManifest throws Manifest consent expired after that — but the renderer sheet stays on screen, armed, indefinitely, and outstandingToken() reuse never refreshes expiresAt, so a second tab coalescing onto a token minted 4.5 min earlier gets one that dies 30 s later; sheets queued behind a slow first consent age the same way. Concrete failure: user opens the consent sheet, walks away 5+ minutes, comes back and clicks Allowdecide throws → the page's swarm request fails with an internal error (Error invoking remote method 'swarm:manifest-decide': … expired) instead of the consent applying; the renderer drops its cache entry so a page retry re-prompts, but the user's explicit answer was discarded. Empirically confirmed with a throwaway jest spec against the real module (since deleted): decide after TTL throws on both a fresh and a reused token. Fix suggestion: refresh pending.expiresAt when outstandingToken reuses a match, and on an expired decide either re-run the check + replay or have the renderer close/refresh the sheet at TTL instead of leaving dead-on-arrival buttons.

  2. Guard-ordering nit retained from earlier R1 minor Swarm encrypted references are not supported #1 — and a recommendation to close the rest of that finding as by-design. The prune-on-'unsupported' behavior that finding asked to remove is explicitly specified in research/permission-manifest-design.md §5.4 ("A previously Bzz-manifest-tracked named origin is pruned before that fallback so managed authority cannot cross the unsupported transport boundary" — the v3.1 "fail-closed transport-switch handling"), and the ENS-contenthash-moves-to-IPFS variant lands on the same specified rule via resolveEnsToGatewayUrl's 404 (bzz-protocol.js:203-208) → absent → prune. What remains actionable is only the ordering: normalizeOrigin(committedUrl) !== key (permission-manifests.js:414) is checked after the prune branch at line 411, so a mismatched origin/committedUrl pair (unreachable from today's renderer — both derive from displayUrl) could prune origin A's record based on origin B's manifest state. Cheap defense-in-depth: move the mismatch guard above the found.status !== 'found' handling.

Previously-raised items still open at 3460107 (tracked, not re-reported): earlier-R1 minors 2–4 (individual connection grant outside the journal; fingerprint ignoring why texts → receipt provenance drift; feed-store/identity user mutations not detaching manifest ownership), R2 minors 1–2 (signature-in-flight pre-check missing on the manifest sheet; checkManifest else-branch saving a pre-await store snapshot), R3 minor 1 (no deadline on the manifest body read — the wrongful-prune half was fixed in 3460107; the multi-minute-hang half remains).

Verification this round: re-reviewed the R3 fix — the transport marker cleanly separates read failures (→ unresolved/backoff) from held-bytes validation failures (→ invalid/prune), the reader is now cancelled on the mid-body error path, and the sibling manual-reader sites (src/main/http-fetch.js, src/main/tezos-domains-resolver.js) don't share the misclassification. All 7 manifest/permission/lock/provider unit suites pass (135 tests). Harness e2e tabs.spec.js + permissions.spec.js: 7/7 (app-launch regression from the earlier R1 remains fixed). Traced the mutation-notification graph (revokePermission/setAutoApprove/grantMessaging/revokeMessagingdetachManaged) for re-entrancy and lock coverage — no defect found; manifest-sourced projections correctly skip user-mutation notification. Working tree left clean; throwaway repro deleted.

@meinharrd meinharrd added alan:clean alan loop finished: no confirmed blocking findings and removed alan:reviewing alan loop currently running on this PR labels Aug 12, 2026
…ch guard

- outstandingToken reuse now extends the shared consent's expiresAt, so a
  second tab coalescing onto a token minted 4.5 min into the 5 min TTL no
  longer inherits a window that dies 30 s later. Test added (fake timers).
- Move the normalizeOrigin(committedUrl) !== key mismatch guard above the
  prune branch so origin A's record can never be pruned on origin B's
  manifest state (defense-in-depth; unreachable from today's renderer).
@meinharrd

Copy link
Copy Markdown
Contributor

Fresh alan review confirms alan:clean, 0 blocking — the prior loop's needs-human was stale (its final-round commit 3460107 had already fixed the R3 mid-body-prune blocker; verified: transport errors → unresolved/backoff, reader cancelled, 42 manifest tests pass).

Fixed the 2 fresh R1 minors in 9984f06:

  1. Consent-token TTL on reuseoutstandingToken reuse now extends the shared consent's expiresAt, so a second tab coalescing onto a token 4.5 min into the 5 min TTL no longer gets one that dies 30 s later. Fake-timer test added (43 tests now pass).
  2. Origin-mismatch guard ordering — moved the normalizeOrigin(committedUrl) !== key guard above the prune branch (defense-in-depth: origin A's record can't be pruned on origin B's state).

The tracked backlog minors (individual-grant journaling, fingerprint why provenance, feed/identity mutation → manifest detach, manifest-sheet signature-in-flight pre-check, checkManifest pre-await snapshot, body-read deadline) remain as noted follow-ups. Guard-ordering finding #2 from R1 confirmed by-design per research/permission-manifest-design.md §5.4.

Six tracked follow-ups from earlier review rounds, all previously
non-blocking:

- `individual` decisions journal their connection grant through
  runTransaction instead of granting ahead of it, so a crash between the
  two no longer leaves an origin connected with nothing to replay.
- Receipts attest the rawHash of the bytes their sheet was built from
  (carried on the consent token) rather than the latest observation, so a
  wording-only redeploy during an outstanding consent can no longer pair
  new bytes with the wording the user actually saw. The semantic
  fingerprint stays schema + capability keys per design §6.1.
- feed-store reports user mutations (feed grant, active publisher
  identity) to the manifest layer like swarm-permissions already does, so
  a hand-made change detaches manifest ownership instead of leaving a
  record that claims — and later re-asserts — the flag. Manifest
  projections pass `source: 'manifest'`; IPC forces `source: 'user'`.
- The manifest consent sheet pre-checks isSignatureInFlight() like its
  siblings; the messaging prompt, which was missing the same guard, gets
  it too.
- checkManifest's no-mutation branch re-reads the store after the
  discover() await instead of saving the pre-await snapshot.
- The manifest body read has a 15s inactivity deadline, so a gateway that
  sends headers then stalls is aborted and classified `unresolved`
  instead of hanging every non-public swarm call for the origin.

Tests: each fix has a unit test that fails against the pre-fix module.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 14, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 14, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R4] The six tracked backlog minors are fixed in 1257153. Nothing blocking was open; this round is polish only.

earlier-R1 minor 2 — individual connection grant outside the journal (permission-manifests.js)
permissions.grantPermission() moved out of the pre-runTransaction block and into the operation list as {projection:'connection', enabled:true}, so it is written to state.pending before it is applied and replayed by recoverPending() like every other mutation. record.detached.connection is still set first, so removeOwners can't emit a competing connection:false op. New recovery test (permission-manifests.recovery.test.js, real stores): a crash at after-journal leaves the origin not connected (previously it was connected with no record at all), and a restart brings back both the connection and the four individual acknowledgements.

earlier-R1 minor 3 — fingerprint vs. why texts → receipt provenance — fixed, but not by folding why into the fingerprint.
research/permission-manifest-design.md §6.1 specifies the semantic fingerprint as "the schema identifier plus sorted capability keys", and states the wording rule explicitly: "A redeploy that edits only description/why/whitespace updates observed.rawHash, but existing acknowledged.*.whyShown and receipts remain what the user actually saw; new wording appears only on a future sheet containing that row." So re-prompting on a wording-only change is by design, and widening the fingerprint would also have re-introduced a Manifest consent is stale throw on the exact path R2-F1 removed.

The actual harm the finding named — "the receipt then attests text that isn't in the manifest it hashes" — is real and is fixed at its source: the consent token now carries the rawHash of the bytes its sheet was built from, and the receipt records that hash instead of record.observed.rawHash (which moves under an outstanding token when a sibling tab re-checks a re-worded redeploy — the widening ac35f64 introduced). Receipt wording and receipt hash now always come from the same manifest. New test: with a wording-only redeploy observed mid-consent, receipt.rows[0].whyShown === 'First wording', receipt.rawHash equals the sha256 of the first manifest's bytes, and record.observed.rawHash differs from it.

earlier-R1 minor 4 — feed-store / identity user mutations don't detach (feed-store.js)
feed-store grew the same onManifestMutation/notifyManifestMutation hook swarm-permissions.js has, and registerPermissionManifestIpc() now registers detachManaged with both stores. User-sourced revokeFeedAccess/grantFeedAccess report feedGrant; a user-driven change of the active publisher identity (createAppScopedIdentity, ensureAntWalletIdentity, ensureEthereumWalletIdentity, activateIdentity) reports identity — only when the active id actually changes, so re-ensuring the identity already in use detaches nothing. Manifest projections pass {source:'manifest'} and notify nothing (without that, disconnect()'s own feedGrant:false op would re-enter detachManaged mid-transaction). The three identity IPC handlers force source: 'user' over whatever the renderer sent, so a compromised renderer can't mutate an identity while suppressing the detach. Five new feed-store tests.

R2 minor 1 — signature-in-flight pre-check on the manifest sheet (permission-manifest.js)
showPermissionManifest() now returns Promise.reject(signatureInFlightError()) before touching manifestQueue or the outcome cache, so the page gets the standard -32002 instead of a generic internal error, the refused token isn't cached against a rejected promise, and no queue entry can ever be shifted into a present() that throws. Sibling parity (per the repo lesson on structurally-identical sites): showSwarmMessagingApproval was missing this same guard — the R2 finding described all four siblings as having it, but messaging did not. Added there too, with a test that drives all four swarm prompts plus the manifest sheet in one pass.

R2 minor 2 — checkManifest else-branch pre-await snapshot
The no-mutation branch now re-loadStore()s after discover() and mutates that, as runTransaction does. New test reproduces the finding exactly: a concurrent operation on another origin fails its atomic write mid-discover (nulling storeCache); before the fix the branch serialized null over the store file and every manifest record was lost on the next load — now the record survives on disk and in memory.

R3 minor 1 (remaining half) — no deadline on the body read
readLimitedBody wraps each reader.read() (and the no-reader arrayBuffer() fallback) in a 15s inactivity deadline — reset per chunk, per the #158 lesson, so a slow-but-steady transfer is unaffected while a half-open socket is bounded. Expiry cancels the reader and funnels through transportError, so it classifies unresolved and feeds the backoff rather than pruning. Two tests: a body that stalls after one chunk resolves unresolved (and the stream is cancelled) instead of hanging; a body trickled 10 bytes at a time across several deadline windows still resolves found.

Verification

  • npx jest src/main/swarm src/renderer/lib/wallet/permission-manage.test.js: 17 suites, 537 tests, all pass. Full src/renderer: 48 suites, 785 tests, all pass.
  • Every new test was run against the pre-fix modules first: 12 of them fail there and pass after (the trickle-read test is a regression guard for the new deadline and passes both ways).
  • npx eslint clean on all 9 touched files.
  • Harness e2e: tabs.spec.js 2/2. Acceptance evidence below came from a throwaway harness spec (deleted before commit) that drives the real app to a live Ledger confirmation, then issues a manifest consent request.

Acceptance evidence — the sheet refuses to open over the device prompt (showPermissionManifest rejects -32002, #sidebar-swarm-manifest never paints, the confirmation stays put), and the same request gets its sheet once the device is done:

manifest-refused-in-flight
manifest-sheet-after-flight

Working tree left clean; throwaway spec deleted. With this the tracked backlog is empty — earlier-R1 minor 1 (prune on 'unsupported') stays closed as by-design per design §5.4, and its guard-ordering half was fixed in 9984f06.

@meinharrd meinharrd added alan:reviewing alan loop currently running on this PR and removed alan:clean alan loop finished: no confirmed blocking findings labels Aug 14, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 14, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (fresh loop, reviewing at 1257153 — focus on the two commits no round has reviewed yet: 9984f06, 1257153) — no blocking findings. Minor findings (combined):

  1. readLimitedBody's no-reader fallback never cancels the body on its failure exits (src/main/swarm/permission-manifests.js:170-181). The reader path cancels on both the idle-timeout/transport error and the 8 KiB cap, but the response.arrayBuffer() fallback does neither — when withIdleDeadline expires (or the buffer is oversized) the underlying stream is left pulling bytes nobody reads until undici's own timeout/GC. Per the fix(context-menu): support bzz/ipfs/ipns URLs in save and copy image #158 lesson this should cancel (response.body?.cancel()) on every exit path. Barely reachable in production — real undici responses always expose body.getReader, so the fallback exists mainly for test doubles — hence minor.

  2. The new signature-in-flight pre-check also rejects the no-UI replay/coalesce paths (src/renderer/lib/wallet/permission-manifest.js:68). The guard added in 1257153 runs before the outcomesByToken lookup, so a tab arriving on an already-answered (or currently-queued) shared token — which needs no sheet at all — now gets -32002 while a device confirmation is up, where before it would silently ride the cached answer. The renderer cache entry is dropped and a page retry succeeds, so impact is a transient spurious rejection; moving the pre-check after the tracked/in-flight lookup would confine it to requests that actually need the screen.

  3. PR description is stale on targeting — "This PR targets feature/window-swarm-enhancements because messaging is part of the capability registry and projection model", but the PR was rebuilt and retargeted to main after feat(swarm): window.swarm messaging extension (PSS + GSOC) #165 merged. Per the fix(main): honor dapp context menu suppression #167 lesson, the description should match the final shape before merge (the Validation section's suite counts also predate the five fix commits).

Verification this round:

  • Reviewed the two unreviewed commits in depth: 9984f06 (TTL refresh on token reuse — correct, candidate expiresAt propagated to the shared token; origin-mismatch guard now precedes every prune branch, and normalizeOrigin never throws on the newly-reachable unparseable-URL inputs) and 1257153 (all six backlog minors closed as described; traced the new feeds.onManifestMutation → detachManaged graph for re-entrancy — manifest-sourced projections correctly suppress notification, IPC forces source:'user', feed/identity IPC and manifest IPC share the same normalized per-origin lock, and feed-store only ever reports feedGrant/identity so the disconnect() re-entry path is unreachable; the journaled individual connection grant orders correctly and its crash test uses real stores).
  • Unit: all swarm/manifest-adjacent suites (src/main/swarm/, renderer manifest/connect/provider, preload) — 20 suites, 556 tests, all pass. eslint clean on all touched files.
  • Harness e2e at HEAD: tabs.spec.js + permissions.spec.js + publisher-identity-selector.spec.js — 8/8 (app-launch regression from the earlier loop stays fixed).
  • Visual acceptance at 1257153 (throwaway spec, deleted): drove the real sheet in the running app — renders correctly, buttons disabled through the 500 ms arm window then live, "Allow all" resolves the request 'allow' and returns the sidebar to the identity view:

r1-manifest-sheet-head

Working tree left clean; throwaway spec and test artifacts deleted.

@meinharrd meinharrd added alan:clean alan loop finished: no confirmed blocking findings and removed alan:reviewing alan loop currently running on this PR labels Aug 14, 2026
@meinharrd
meinharrd merged commit 5e52241 into main Aug 14, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alan:clean alan loop finished: no confirmed blocking findings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants